/*
 * Copyright 2010 Alessio Stalla.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package dynaspring;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.HashMap;
import java.util.Map;

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanClassLoaderAware;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ConfigurableApplicationContext;

/**
 * A factory that returns a singleton implementation of a given interface T backed by an application context and,
 * optionally, an implementation object (which is not required to implement T).
 * Getter methods in the interface will be wired to getBean("...") on the context, and setter methods will inject
 * singleton beans in the context. Some methods in the interface are used as lifecycle callbacks (initMethod, destroyMethod),
 * and additional methods (if present) are forwarded to methods with the same name and parameters in the optional implementation object.
 * <br/><br />
 * This class allows to implement Spring-backed modules defined by an interface. Getter methods in the interface represent beans
 * that are exported from the underlying application context; setter methods represent dependencies of the module which must be
 * provided before the module is initialized. 
 * 
 * @author Alessio Stalla
 *
 * @param <T> the proxied interface.
 */
public class ApplicationContextProxyFactory<T> implements InitializingBean, DisposableBean, BeanClassLoaderAware {
	
	protected ConfigurableApplicationContext applicationContext;
	protected Class<T> proxyInterface;
	protected T targetObject;
	protected boolean targetObjectParticipatesInLifecycle = true;
	protected T proxy;
	protected final Map<Method, MethodImpl> implementationMap = new HashMap<Method, ApplicationContextProxyFactory.MethodImpl>();
	protected ClassLoader classLoader;
	/**
	 * Names of the init and destroy method, to make configuration with Spring easier.
	 * Should not be set if init/destroyMethod are already set.
	 */
	protected String initMethodName, destroyMethodName;
	protected Method initMethod, destroyMethod;
	
	public T getProxy() {
		return proxy;
	}
	
	/**
	 * Called by the proxy's getter methods to fetch beans from the context. 
	 * @param beanName
	 * @param beanType
	 * @return
	 * @throws Exception
	 */
	protected Object getBean(String beanName, Class<?> beanType) throws Exception {
		ensureProxyIsInitialized();
		return applicationContext.getBean(beanName, beanType);
	}

	/**
	 * Called by the proxy's setter methods to register beans in the context.
	 * @param registrar
	 * @param beanName
	 * @param object
	 */
	protected void putBean(SingletonRegistrar registrar, String beanName, Object object) {
		if(applicationContext.isActive()) {
			throw new IllegalStateException("Application context " + applicationContext + " has been already started, no other beans can be registered on it.");
		}
		registrar.singletons.put(beanName, object);
	}
	
	protected void initProxy() throws Exception {
		applicationContext.refresh();
		if(targetObjectParticipatesInLifecycle && targetObject instanceof InitializingBean) {
			((InitializingBean) targetObject).afterPropertiesSet();
		}
	}
	
	protected void destroyProxy() throws Exception {
		if(targetObjectParticipatesInLifecycle && targetObject instanceof DisposableBean) {
			((DisposableBean) targetObject).destroy();
		}
		applicationContext.close();
	}
	
	@Override
	public void afterPropertiesSet() throws Exception {
		if(applicationContext == null) { throw new NullPointerException("applicationContext cannot be null"); }
		if(proxyInterface == null) { throw new NullPointerException("proxyInterface cannot be null"); }
		if(classLoader == null) { classLoader = proxyInterface.getClassLoader(); }
		
		//Init method resolution
		if(initMethod == null) {
			if(initMethodName != null) {
				initMethod = proxyInterface.getMethod(initMethodName);
			} else if(InitializingBean.class.isAssignableFrom(proxyInterface)) {
				initMethod = InitializingBean.class.getMethod("afterPropertiesSet"); 
			}
		}
		initMethodName = initMethod != null ? initMethod.getName() : null;
		
		//Destroy method resolution
		if(destroyMethod == null) {
			if(destroyMethodName != null) {
				destroyMethod = proxyInterface.getMethod(destroyMethodName);
			} else if(DisposableBean.class.isAssignableFrom(proxyInterface)) {
				destroyMethod = DisposableBean.class.getMethod("destroy"); 
			}
		}
		destroyMethodName = destroyMethod != null ? destroyMethod.getName() : null;
		
		final SingletonRegistrar registrar = new SingletonRegistrar();
		initInterfaceImplementation(registrar);
		applicationContext.addBeanFactoryPostProcessor(registrar);
		if(targetObject instanceof ApplicationContextAware) {
			((ApplicationContextAware) targetObject).setApplicationContext(applicationContext);
		}
		proxy = makeProxy();
	}
	
	@Override
	public void destroy() throws Exception {
		if(destroyMethod == null) {
			destroyProxy();
		} //Else destruction is done by the proxy
	}
	
	/**
	 * Checks if the context is initialized, and if it's not, either initializes it or throws an exception depending on whether
	 * an init-method has been specified.<br />
	 * If there's no init method, we do not know when to initialize the proxy, so we have to do it when we first ask a bean to
	 * the context. Else initialization is done by the proxy when it's initialized by the context; if the proxy interface does not
	 * extend InitializingBean, care must be taken to make sure that the same init method is specified for both this factory and the proxy.
	 * @throws Exception
	 */
	protected void ensureProxyIsInitialized() throws Exception {
		if(!applicationContext.isActive()) {
			if(initMethod == null) { 
				initProxy();
			} else {
				throw new IllegalStateException("Access to the context before init method (" + initMethod + ") has been called.");
			}
		}
	}

	protected static final Method objectToString;
	protected static final Method objectHashCode;
	protected static final Method objectEquals;
	
	static {
		try {
			objectToString = Object.class.getMethod("toString");
			objectHashCode = Object.class.getMethod("hashCode");
			objectEquals = Object.class.getMethod("equals", Object.class);
		} catch (Exception e) {
			throw new RuntimeException(e);
		}
	}
	
	protected void initInterfaceImplementation(final SingletonRegistrar registrar) {
		//Default Object implementations
		implementationMap.put(objectToString, new MethodImpl() {
			@Override
			public Object invoke(Object target, Object... args) {
				return proxyInterface.getName() + " proxy " + System.identityHashCode(target);
			}
		});
		implementationMap.put(objectHashCode, new MethodImpl() {
			@Override
			public Object invoke(Object target, Object... args) {
				return System.identityHashCode(target);
			}
		});
		implementationMap.put(objectEquals, new MethodImpl() {
			@Override
			public Object invoke(Object target, Object... args) {
				return target == args[0];
			}
		});
		//Interface method implementations
		for(Method m : proxyInterface.getMethods()) {
			if(m.getName().startsWith("get") && m.getParameterTypes().length == 0) {
				final String beanName;
				beanName = beanNameFromMethodName(m);
				final Class<?> beanType = m.getReturnType();
				implementationMap.put(m, new MethodImpl() {
					@Override
					public Object invoke(Object target, Object... args) throws Exception {
						return getBean(beanName, beanType);
					}
				});
			} else if(m.getName().startsWith("set") && m.getParameterTypes().length == 1) {
				final String beanName;
				beanName = beanNameFromMethodName(m);
				implementationMap.put(m, new MethodImpl() {
					@Override
					public Object invoke(Object target, Object... args) {
						putBean(registrar, beanName, args[0]);
						return null;
					}
				});
			} else if(m.equals(initMethod)) {
				implementationMap.put(m, new MethodImpl() {
					@Override
					public Object invoke(Object target, Object... args) throws Exception {
						initProxy();
						return null;
					}
				});
			} else if(m.equals(destroyMethod)) {
				implementationMap.put(m, new MethodImpl() {
					@Override
					public Object invoke(Object target, Object... args) throws Exception {
						destroyProxy();
						return null;
					}
				});
			} else {
				if(targetObject != null) {
					Class<?> tClass = targetObject.getClass();
					try {
						Method tMethod = tClass.getMethod(m.getName(), m.getParameterTypes());
						implementationMap.put(m, new MethodForward(targetObject, tMethod));
					} catch(Exception e) {
						throw new IllegalArgumentException("Method " + m + " not found in target object " + targetObject, e);	
					}
				} else {
					throw new IllegalArgumentException("non-getter/setter method " + m + " not supported when no target object is set");
				}
			}
		}
	}

	protected String beanNameFromMethodName(Method m) {
		return m.getName().substring(3, 4).toLowerCase() + m.getName().substring(4);
	}
	
	protected static class SingletonRegistrar implements BeanFactoryPostProcessor {
		protected final Map<String, Object> singletons = new HashMap<String, Object>();
		@Override
		public void postProcessBeanFactory(ConfigurableListableBeanFactory factory) throws BeansException {
			for(Map.Entry<String, Object> e : singletons.entrySet()) {
				factory.registerSingleton(e.getKey(), e.getValue());
			}
		}
	}
	
	@SuppressWarnings("unchecked")
	protected T makeProxy() {
		return (T) Proxy.newProxyInstance(classLoader, new Class<?>[] { proxyInterface }, new MapInvocationHandler());
	}
	
	protected class MapInvocationHandler implements InvocationHandler {
		@Override
		public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
			return implementationMap.get(method).invoke(proxy, args);
		}
	}
	
	protected static abstract class MethodImpl {
		public abstract Object invoke(Object target, Object... args) throws Exception;
	}
	
	protected static class MethodForward extends MethodImpl {
		private Object instance;
		private Method method;
		public MethodForward(Object instance, Method method) { this.instance = instance; this.method = method; }
		public Object invoke(Object target, Object... args) throws Exception {
			return method.invoke(instance, args);
		}
	}
	
	public ConfigurableApplicationContext getApplicationContext() {
		return applicationContext;
	}

	public void setApplicationContext(ConfigurableApplicationContext applicationContext) {
		this.applicationContext = applicationContext;
	}

	public T getTargetObject() {
		return targetObject;
	}

	/**
	 * Sets the object used to implement non-getter/setter method of the proxy interface. Such an object is required if the interface
	 * has any other methods besides getters and setters, init and destroy methods. Note: if this object implements InitializingBean
	 * and/or DisposableBean, its afterPropertiesSet() and/or destroy() methods will be called by this factory respectively after the
	 * context has been refreshed and before the context is closed. This implies that if the object implements one of those interfaces
	 * it should not be managed by the Spring container, or those methods will be called twice. If you do want the target object to be
	 * provided by Spring, set the targetObjectParticipatesInLifecycle property to false.
	 * @param targetObject
	 */
	public void setTargetObject(T targetObject) {
		this.targetObject = targetObject;
	}
	
	public boolean isTargetObjectParticipatesInLifecycle() {
		return targetObjectParticipatesInLifecycle;
	}
	
	/**
	 * If set to true (the default), init and destroy callbacks will be notified to the target object if it's not null and if it
	 * implements the relevant interfaces (InitializingBean and DisposableBean). If false, the target object won't be notified.
	 * Set it to false if the object is managed by Spring, to avoid callback methods being called twice (once by Spring and once by
	 * this object).
	 * @param targetObjectParticipatesInLifecycle
	 */
	public void setTargetObjectParticipatesInLifecycle(boolean targetObjectParticipatesInLifecycle) {
		this.targetObjectParticipatesInLifecycle = targetObjectParticipatesInLifecycle;
	}
	
	public Class<T> getProxyInterface() {
		return proxyInterface;
	}
	
	public void setProxyInterface(Class<T> proxyInterface) {
		this.proxyInterface = proxyInterface;
	}

	public ClassLoader getBeanClassLoader() {
		return classLoader;
	}
	
	public Method getInitMethod() {
		return initMethod;
	}
	
	public void setInitMethod(Method initMethod) {
		this.initMethod = initMethod;
	}
	
	public String getInitMethodName() {
		return initMethodName;
	}
	
	public void setInitMethodName(String name) {
		this.initMethodName = name;
	}
	
	public Method getDestroyMethod() {
		return destroyMethod;
	}
	
	public void setDestroyMethod(Method destroyMethod) {
		this.destroyMethod = destroyMethod;
	}
	
	public String getDestroyMethodName() {
		return destroyMethodName;
	}
	
	public void setDestroyMethodName(String destroyMethodName) {
		this.destroyMethodName = destroyMethodName;
	}
	
	@Override
	public void setBeanClassLoader(ClassLoader classLoader) {
		this.classLoader = classLoader;	
	}

}
